#!/usr/bin/python2

# ** The MIT License **
#
# Copyright (c) 2007 Eric Davis (aka Insanum)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Dude... just buy me a beer. :-)
#

#
# Home: http://code.google.com/p/gcalcli
#
# Author: Eric Davis <http://www.insanum.com>
#
# Requirements:
#  - Python 2
#        http://www.python.org
#  - Google's GData Python module (for Python 2)
#        http://code.google.com/p/gdata-python-client
#  - ElementTree Python module
#        http://effbot.org/zone/element-index.htm
#  - dateutil Python module
#        http://www.labix.org/python-dateutil
#  - vobject Python module (optional, needed for importing ics/vcal files)
#        http://vobject.skyhouseconsulting.com
#

__program__ = 'gcalcli'
__version__ = 'v2.1'
__author__  = 'Eric Davis'

import inspect

import sys, os, re, urllib, getopt, shlex
import codecs, locale, csv, threading, getpass
from Queue import Queue
from ConfigParser import RawConfigParser
from gdata.calendar.service import *
from datetime import *
from dateutil.tz import *
from dateutil.parser import *
from unicodedata import east_asian_width


def Version():
    sys.stdout.write(__program__+' '+__version__+' ('+__author__+')\n')
    sys.exit(1)


def Usage():
    sys.stdout.write('''
Usage:

gcalcli [options] command [command args]

 Options:

  --help                   this usage text

  --version                version information

  --config <file>          config file to read (default is '~/.gcalclirc')

  --user <username>        google username

  --pw <password>          password

  --https                  use HTTPS when communicating with Google

  --cals [all,             'calendars' to work with (default is all calendars)
          default,         - default (your default main calendar)
          owner,           - owner (your owned calendars)
          editor,          - editor (editable calendar)
          contributor,     - contributor (non-owner but able to edit)
          read,            - read (read only calendars)
          freebusy]        - freebusy (only free/busy info visible)

  --cal <name>[#color]     'calendar' to work with (default is all calendars)
                           - you can specify a calendar by name or by substring
                             which can match multiple calendars
                           - you can use multiple '--cal' arguments on the
                             command line
                           - in the config file specify multiple calendars in
                             quotes separated by commas as:
                               cal: "foo", "bar", "my cal"
                           - an optional color override can be specified per
                             calendar using the ending hashtag:
                               --cal "Eric Davis"#green --cal foo#red
                             or via the config file:
                               cal: "foo"#red, "bar"#yellow, "my cal"#green

  --24hr                   show all dates in 24 hour format

  --details                show all event details (i.e. length, location,
                           reminders, contents)

  --ignore-started         ignore old or already started events
                           - when used with the 'agenda' command, ignore events
                             that have already started and are in-progress with
                             respect to the specified [start] time
                           - when used with the 'search' command, ignore events
                             that have already occurred and only show future
                             events

  --width                  the number of characters to use for each column in
                           the 'calw' and 'calm' command outputs (default is 10)

  --mon                    week begins with Monday for 'calw' and 'calm' command
                           outputs (default is Sunday)

  --nc                     don't use colors

  --cal-owner-color        specify the colors used for the calendars and dates
  --cal-editor-color       each of these argument requires a <color> argument
  --cal-contributor-color  which must be one of [ default, black, brightblack,
  --cal-read-color         red, brightred, green, brightgreen, yellow,
  --cal-freebusy-color     brightyellow, blue, brightblue, magenta,
  --date-color             brightmagenta, cyan, brightcyan, white,
  --border-color           brightwhite ]

 Commands:

  list                     list all calendars

  search <text>            search for events
                           - only matches whole words

  agenda [start] [end]     get an agenda for a time period
                           - start time default is 12am today
                           - end time default is 5 days from start
                           - example time strings:
                              '9/24/2007'
                              'Sep 24 2007 3:30pm'
                              '2007-09-24T15:30'
                              '2007-09-24T15:30-8:00'
                              '20070924T15'
                              '8am'

  calw <weeks> [start]     get a week based agenda in a nice calendar format
                           - weeks is the number of weeks to display
                           - start time default is beginning of this week
                           - note that all events for the week(s) are displayed

  calm [start]             get a month agenda in a nice calendar format
                           - start time default is the beginning of this month
                           - note that all events for the month are displayed
                             and only one month will be displayed

  quick <text>             quick add an event to a calendar
                           - if a --cal is not specified then the event is
                             added to the default calendar
                           - example:
                              'Dinner with Eric 7pm tomorrow'
                              '5pm 10/31 Trick or Treat'

  import [file]            import an ics/vcal file to a calendar
                           - if a --cal is not specified then the event is
                             added to the default calendar
                           - if a file is not specified then the data is read
                             from standard input

  remind <mins> <command>  execute command if event occurs within <mins>
                           minutes time ('%s' in <command> is replaced with
                           event start time and title text)
                           - <mins> default is 10
                           - default command:
                              'gxmessage -display :0 -center \\
                                         -title "Ding, Ding, Ding!" %s'
''')
    sys.exit(1)


class CLR:

    useColor = True

    def __str__(self):
        if self.useColor: return self.color
        else: return ""

class CLR_NRM(CLR):   color = "\033[0m"
class CLR_BLK(CLR):   color = "\033[0;30m"
class CLR_BRBLK(CLR): color = "\033[30;1m"
class CLR_RED(CLR):   color = "\033[0;31m"
class CLR_BRRED(CLR): color = "\033[31;1m"
class CLR_GRN(CLR):   color = "\033[0;32m"
class CLR_BRGRN(CLR): color = "\033[32;1m"
class CLR_YLW(CLR):   color = "\033[0;33m"
class CLR_BRYLW(CLR): color = "\033[33;1m"
class CLR_BLU(CLR):   color = "\033[0;34m"
class CLR_BRBLU(CLR): color = "\033[34;1m"
class CLR_MAG(CLR):   color = "\033[0;35m"
class CLR_BRMAG(CLR): color = "\033[35;1m"
class CLR_CYN(CLR):   color = "\033[0;36m"
class CLR_BRCYN(CLR): color = "\033[36;1m"
class CLR_WHT(CLR):   color = "\033[0;37m"
class CLR_BRWHT(CLR): color = "\033[37;1m"


def PrintErrMsg(msg):
    if CLR.useColor:
        sys.stdout.write(str(CLR_BRRED()))
        sys.stdout.write(msg)
        sys.stdout.write(str(CLR_NRM()))
    else:
        sys.stdout.write(msg)


def PrintMsg(color, msg):
    if CLR.useColor:
        sys.stdout.write(str(color))
        sys.stdout.write(msg)
        sys.stdout.write(str(CLR_NRM()))
    else:
        sys.stdout.write(msg)


def DebugPrint(msg):
    return
    sys.stdout.write(str(CLR_YLW()))
    sys.stdout.write(msg)
    sys.stdout.write(str(CLR_NRM()))


class gcalcli:

    gcal          = None
    allCals       = None
    cals          = []
    now           = datetime.now(tzlocal())
    feedPrefix    = None
    agendaLength  = 5
    username      = None
    password      = None
    access        = ''
    military      = False
    details       = False
    ignoreStarted = False
    calWidth      = 10
    calMonday     = False
    command       = \
        'gxmessage -display :0 -center -title "Ding, Ding, Ding!" %s'

    calOwnerColor       = CLR_CYN()
    calEditorColor      = CLR_NRM()
    calContributorColor = CLR_NRM()
    calReadColor        = CLR_MAG()
    calFreeBusyColor    = CLR_NRM()
    dateColor           = CLR_YLW()
    borderColor         = CLR_WHT()

    ACCESS_ALL         = 'all'      # non-google access level
    ACCESS_DEFAULT     = 'default'  # non-google access level
    ACCESS_CONTRIBUTOR = 'contributor'
    ACCESS_EDITOR      = 'editor'
    ACCESS_FREEBUSY    = 'freebusy'
    ACCESS_NONE        = 'none'
    ACCESS_OVERRIDE    = 'override'
    ACCESS_OWNER       = 'owner'
    ACCESS_READ        = 'read'
    ACCESS_RESPOND     = 'respond'
    ACCESS_ROOT        = 'root'


    def __init__(self,
                 username=None,
                 password=None,
                 https=False,
                 access='all',
                 calNames=[],
                 calNameColors=[],
                 military=False,
                 details=False,
                 ignoreStarted=False,
                 calWidth=10,
                 calMonday=False,
                 calOwnerColor=CLR_CYN(),
                 calEditorColor=CLR_GRN(),
                 calContributorColor=CLR_NRM(),
                 calReadColor=CLR_MAG(),
                 calFreeBusyColor=CLR_NRM(),
                 dateColor=CLR_GRN(),
                 borderColor=CLR_WHT()):

        self.gcal          = CalendarService()
        self.username      = username
        self.password      = password
        self.https         = https

        if https: self.feedPrefix = 'https://'
        else:     self.feedPrefix = 'http://'
        self.feedPrefix = self.feedPrefix + 'www.google.com/calendar/feeds/'

        self.access        = access
        self.military      = military
        self.details       = details
        self.ignoreStarted = ignoreStarted
        self.calWidth      = calWidth
        self.calMonday     = calMonday

        self.calOwnerColor       = calOwnerColor
        self.calEditorColor      = calEditorColor
        self.calContributorColor = calContributorColor
        self.calReadColor        = calReadColor
        self.calFreeBusyColor    = calFreeBusyColor
        self.dateColor           = dateColor
        self.borderColor         = borderColor

        # authenticate and login to google calendar
        try:
            self.gcal.ClientLogin(
                            username=self.username,
                            password=self.password,
                            service='cl',
                            source=__author__+'-'+__program__+'-'+__version__)
        except Exception, e:
            PrintErrMsg("Error: " + str(e) + "!\n")
            sys.exit(1)

        # get the list of calendars
        self.allCals = self.gcal.GetAllCalendarsFeed()

        # gcalcli defined way to order calendars XXX
        order = { self.ACCESS_OWNER       : 1,
                  self.ACCESS_EDITOR      : 2,
                  self.ACCESS_ROOT        : 3,
                  self.ACCESS_CONTRIBUTOR : 4,
                  self.ACCESS_OVERRIDE    : 5,
                  self.ACCESS_RESPOND     : 6,
                  self.ACCESS_FREEBUSY    : 7,
                  self.ACCESS_READ        : 8,
                  self.ACCESS_NONE        : 9 }

        self.allCals.entry.sort(lambda x, y:
                                cmp(order[x.access_level.value],
                                    order[y.access_level.value]))

        for cal in self.allCals.entry:

            cal.gcalcli_altLink = cal.GetAlternateLink().href

            if self.https == False and cal.gcalcli_altLink[0:5] == "https":
                PrintErrMsg("Error: Unable to connect, https required!\n")
                sys.exit(1)

            match = re.match('^' + self.feedPrefix + '(.*?)/(.*?)/(.*)$',
                             cal.gcalcli_altLink)
            cal.gcalcli_username   = urllib.unquote(match.group(1))
            cal.gcalcli_visibility = urllib.unquote(match.group(2))
            cal.gcalcli_projection = urllib.unquote(match.group(3))

            if len(calNames):
                for i in xrange(len(calNames)):
                    if re.search(calNames[i].lower(),
                                 cal.title.text.lower()):
                        self.cals.append(cal)
                        cal.colorSpec = calNameColors[i]
            else:
                self.cals.append(cal)
                cal.colorSpec = None


    def _CalendarWithinAccess(self, cal):

        if self.access == self.ACCESS_ALL:
            return True
        elif self.access == self.ACCESS_DEFAULT and \
             cal.gcalcli_username == self.username:
            return True
        elif self.access != cal.access_level.value:
            return False
        else:
            return True


    def _CalendarColor(self, cal):

        if cal == None:
            return CLR_NRM()
        elif hasattr(cal, 'colorSpec') and cal.colorSpec != None:
            return cal.colorSpec
        elif cal.access_level.value == self.ACCESS_OWNER:
            return self.calOwnerColor
        elif cal.access_level.value == self.ACCESS_EDITOR:
            return self.calEditorColor
        elif cal.access_level.value == self.ACCESS_CONTRIBUTOR:
            return self.calContributorColor
        elif cal.access_level.value == self.ACCESS_FREEBUSY:
            return self.calFreeBusyColor
        elif cal.access_level.value == self.ACCESS_READ:
            return self.calReadColor
        else:
            return CLR_NRM()


    def _TargetCalendar(self):

        if len(self.cals) == 1:
            if self.https: prefix = 'https://'
            else:          prefix = 'http://'
            match = re.match('^' + prefix + 'www.google.com(.*)$',
                             self.cals[0].gcalcli_altLink)
            return match.group(1)
        else:
            return '/calendar/feeds/default/private/full'


    def _ValidTitle(self, title):
        if title == None:
            return "(No title)"
        else:
            return title


    def _GetWeekEventStrings(self, cmd, curMonth,
                             startDateTime, endDateTime, eventList):

        weekEventStrings = [ '', '', '', '', '', '', '' ]

        for event in eventList:

            if cmd == 'calm' and curMonth != event.s.strftime("%b"):
                continue

            dayNum = int(event.s.strftime("%w"))
            if self.calMonday:
                dayNum -= 1
                if dayNum < 0:
                    dayNum = 6

            if event.s >= startDateTime and event.s < endDateTime:

                if event.s.hour == 0 and event.s.minute == 0 and \
                   event.e.hour == 0 and event.e.minute == 0:
                    tmpTimeStr = ''
                elif self.military:
                    tmpTimeStr = event.s.strftime("%H:%M")
                else:
                    tmpTimeStr = \
                        event.s.strftime("%I:%M").lstrip('0') + \
                        event.s.strftime('%p').lower()

                # newline and empty string are the keys to turn off coloring
                weekEventStrings[dayNum] += \
                    "\n" + \
                    str(self._CalendarColor(event.gcalcli_cal)) + \
                    tmpTimeStr.strip() + \
                    " " + \
                    self._ValidTitle(event.title.text).strip()

        return weekEventStrings


    UNIWIDTH = {'W': 2, 'F': 2, 'N': 1, 'Na': 1, 'H': 1, 'A': 1}


    def _PrintLen(self, string):
        printLen = 0
        for tmpChar in string:
            printLen += self.UNIWIDTH[east_asian_width(tmpChar)]
        return printLen


    # return print length before cut, cut index, and force cut flag
    def _NextCut(self, string, curPrintLen):
        idx = 0
        printLen = 0
        for tmpChar in string:
            if (curPrintLen + printLen) >= self.calWidth:
                return (printLen, idx, True)
            if tmpChar in (' ', '\n'):
                return (printLen, idx, False)
            idx += 1
            printLen += self.UNIWIDTH[east_asian_width(tmpChar)]
        return (printLen, -1, False)


    def _GetCutIndex(self, eventString):

        printLen = self._PrintLen(eventString)

        if printLen <= self.calWidth:
            DebugPrint("------ printLen=%d (end of string)\n" % printLen)
            return (printLen, len(eventString))

        cutWidth, cut, forceCut = self._NextCut(eventString, 0)
        DebugPrint("------ cutWidth=%d cut=%d \"%s\"\n" %
                   (cutWidth, cut, eventString))

        if forceCut:
            DebugPrint("--- forceCut cutWidth=%d cut=%d\n" % (cutWidth, cut))
            return (cutWidth, cut)

        DebugPrint("--- looping\n")

        while cutWidth < self.calWidth:

            DebugPrint("--- cutWidth=%d cut=%d \"%s\"\n" %
                       (cutWidth, cut, eventString[cut:]))

            while cut < self.calWidth and \
                  cut < printLen and \
                  eventString[cut] == ' ':
                DebugPrint("-> skipping space <-\n")
                cutWidth += 1
                cut += 1

            DebugPrint("--- cutWidth=%d cut=%d \"%s\"\n" %
                       (cutWidth, cut, eventString[cut:]))

            nextCutWidth, nextCut, forceCut = \
                self._NextCut(eventString[cut:], cutWidth)

            if forceCut:
                DebugPrint("--- forceCut cutWidth=%d cut=%d\n" % (cutWidth, cut))
                break

            cutWidth += nextCutWidth
            cut += nextCut

            if eventString[cut] == '\n':
                break

            DebugPrint("--- loop cutWidth=%d cut=%d\n" % (cutWidth, cut))

        return (cutWidth, cut)


    def _GraphEvents(self, cmd, startDateTime, count, eventList):

        # ignore started events (i.e. that start previous day and end start day)
        while (len(eventList) and eventList[0].s < startDateTime):
            eventList = eventList[1:]

        dayDivider = ''
        for i in xrange(self.calWidth):
            dayDivider += '-'

        weekDivider = ''
        for i in xrange(7):
            weekDivider += '+'
            weekDivider += dayDivider
        weekDivider += '+'
        weekDivider = str(self.borderColor) + weekDivider + str(CLR_NRM())

        empty = ''
        for i in xrange(self.calWidth):
            empty += ' '

        dayFormat = '%-' + str(self.calWidth) + '.' + str(self.calWidth) + 's'

        # Get the localized day names... January 1, 2001 was a Monday
        dayNames = [ date(2001, 1, i+1).strftime('%A') for i in range(7) ]
        dayNames = dayNames[6:] + dayNames[:6]

        dayHeader = str(self.borderColor) + '|' + str(CLR_NRM())
        for i in xrange(7):
            if self.calMonday:
                if i == 6:
                    dayName = dayFormat % (dayNames[0])
                else:
                    dayName = dayFormat % (dayNames[i+1])
            else:
                dayName = dayFormat % (dayNames[i])
            dayHeader += str(self.dateColor) + dayName + str(CLR_NRM())
            dayHeader += str(self.borderColor) + '|' + str(CLR_NRM())

        PrintMsg(CLR_NRM(), "\n" + weekDivider + "\n")
        if cmd == 'calm':
            m = startDateTime.strftime('%B %Y')
            mw = str((self.calWidth * 7) + 6)
            mwf = '%-' + mw + '.' + mw + 's'
            PrintMsg(CLR_NRM(),
                     str(self.borderColor) + '|' + str(CLR_NRM()) +
                     str(self.dateColor) + mwf % (m) + str(CLR_NRM()) +
                     str(self.borderColor) + '|' + str(CLR_NRM()) + '\n')
            PrintMsg(CLR_NRM(), weekDivider + "\n")
        PrintMsg(CLR_NRM(), dayHeader + "\n")
        PrintMsg(CLR_NRM(), weekDivider + "\n")

        curMonth = startDateTime.strftime("%b")

        # get date range objects for the first week
        if cmd == 'calm':
            dayNum = int(startDateTime.strftime("%w"))
            if self.calMonday:
                dayNum -= 1
                if dayNum < 0:
                    dayNum = 6
            startDateTime = (startDateTime - timedelta(days=dayNum))
        startWeekDateTime = startDateTime
        endWeekDateTime = (startWeekDateTime + timedelta(days=7))

        for i in xrange(count):

            # create/print date line
            line = str(self.borderColor) + '|' + str(CLR_NRM())
            for j in xrange(7):
                if cmd == 'calw':
                    d = (startWeekDateTime +
                         timedelta(days=j)).strftime("%d %b")
                else: # (cmd == 'calm'):
                    d = (startWeekDateTime +
                         timedelta(days=j)).strftime("%d")
                    if curMonth != (startWeekDateTime + \
                                    timedelta(days=j)).strftime("%b"):
                        d = ''
                todayMarker = ''
                if self.now.strftime("%d%b%Y") == \
                   (startWeekDateTime + timedelta(days=j)).strftime("%d%b%Y"):
                    todayMarker = " **"
                line += str(self.dateColor) + \
                            dayFormat % (d + todayMarker) + \
                        str(CLR_NRM()) + \
                        str(self.borderColor) + \
                            '|' + \
                        str(CLR_NRM())
            PrintMsg(CLR_NRM(), line + "\n")

            weekColorStrings = [ '', '', '', '', '', '', '' ]
            weekEventStrings = self._GetWeekEventStrings(cmd, curMonth,
                                                         startWeekDateTime,
                                                         endWeekDateTime,
                                                         eventList)

            # convert the strings to unicode for various string ops
            for j in xrange(7):
                weekEventStrings[j] = unicode(weekEventStrings[j],
                                              locale.getpreferredencoding())

            # get date range objects for the next week
            startWeekDateTime = endWeekDateTime
            endWeekDateTime = (endWeekDateTime + timedelta(days=7))

            while 1:

                done = True
                line = str(self.borderColor) + '|' + str(CLR_NRM())

                for j in xrange(7):

                    if weekEventStrings[j] == '':
                        weekColorStrings[j] = ''
                        line += empty + \
                                str(self.borderColor) + '|' + str(CLR_NRM())
                        continue

                    if weekEventStrings[j][0] == '\033':
                        # get/skip over color sequence
                        weekColorStrings[j] = ''
                        while (weekEventStrings[j][0] != 'm'):
                            weekColorStrings[j] += weekEventStrings[j][0]
                            weekEventStrings[j] = weekEventStrings[j][1:]
                        weekColorStrings[j] += weekEventStrings[j][0]
                        weekEventStrings[j] = weekEventStrings[j][1:]

                    if weekEventStrings[j][0] == '\n':
                        weekColorStrings[j] = ''
                        weekEventStrings[j] = weekEventStrings[j][1:]
                        line += empty + \
                                str(self.borderColor) + '|' + str(CLR_NRM())
                        done = False
                        continue

                    weekEventStrings[j] = weekEventStrings[j].lstrip()

                    printLen, cut = self._GetCutIndex(weekEventStrings[j])
                    padding = ' ' * (self.calWidth - printLen)

                    line += weekColorStrings[j] + \
                            weekEventStrings[j][:cut] + \
                            padding + \
                            str(CLR_NRM())
                    weekEventStrings[j] = weekEventStrings[j][cut:]

                    done = False
                    line += str(self.borderColor) + '|' + str(CLR_NRM())

                if done:
                    break

                PrintMsg(CLR_NRM(), line + "\n")

            PrintMsg(CLR_NRM(), weekDivider + "\n")


    def _PrintEvents(self, startDateTime, eventList):

        if len(eventList) == 0:
            PrintMsg(CLR_YLW(), "\nNo Events Found...\n")
            return

        dayFormat = '\n%a %b %d' # 10 chars for day
        indent = '          ' # 10 spaces
        detailsIndent = '                   '    # 19 spaces
        day = ''

        for event in eventList:

            if self.ignoreStarted and (event.s < startDateTime):
                continue

            tmpDayStr = event.s.strftime(dayFormat)

            if self.military:
                timeFormat = '%-5s'
                tmpTimeStr = event.s.strftime("%H:%M")
            else:
                timeFormat = '%-7s'
                tmpTimeStr = \
                    event.s.strftime("%I:%M").lstrip('0').rjust(5) + \
                    event.s.strftime('%p').lower()

            prefix = indent
            if tmpDayStr != day: day = prefix = tmpDayStr
            PrintMsg(self.dateColor, prefix)
            if event.s.hour == 0 and event.s.minute == 0 and \
               event.e.hour == 0 and event.e.minute == 0:
                fmt = '  ' + timeFormat + '  %s\n'
                PrintMsg(self._CalendarColor(event.gcalcli_cal), fmt %
                         ('', self._ValidTitle(event.title.text).strip()))
            else:
                fmt = '  ' + timeFormat + '  %s\n'
                PrintMsg(self._CalendarColor(event.gcalcli_cal), fmt %
                         (tmpTimeStr, self._ValidTitle(event.title.text).strip()))

            if self.details:

                clr = CLR_NRM()

                if event.where[0].value_string:
                    str = "%s  Location: %s\n" % (detailsIndent,
                                                 event.where[0].value_string)
                    PrintMsg(clr, str)

                diffDateTime = (event.e - event.s)
                str = "%s  Length: %s\n" % (detailsIndent, diffDateTime)
                PrintMsg(clr, str)

                # XXX Why does accessing event.when[0].reminder[0] fail?
                for rem in event.when[0].reminder:
                    remStr = ''
                    if rem.days:
                        remStr += "%s Days" % (rem.days)
                    if rem.hours:
                        if remStr != '': remStr += ' '
                        remStr += "%s Hours" % (rem.hours)
                    if rem.minutes:
                        if remStr != '': remStr += ' '
                        remStr += "%s Minutes" % (rem.minutes)
                    str = "%s  Reminder: %s\n" % (detailsIndent, remStr)
                    PrintMsg(clr, str)

                if event.content.text:
                    str = "%s  Content: %s\n" % (detailsIndent,
                                                event.content.text)
                    PrintMsg(clr, str)


    def _GetAllEvents(self, cal, feed, end):

        eventList = []

        while 1:
            next = feed.GetNextLink()

            for event in feed.entry:

                event.gcalcli_cal = cal

                event.s = parse(event.when[0].start_time)
                if event.s.tzinfo == None:
                    event.s = event.s.replace(tzinfo=tzlocal())

                event.e = parse(event.when[0].end_time)
                if event.e.tzinfo == None:
                    event.e = event.e.replace(tzinfo=tzlocal())

                # For all-day events, Google seems to assume that the event time
                # is based in the UTC instead of the local timezone.  Here we
                # filter out those events start beyond a specified end time.
                if end and (event.s >= end):
                    continue

                # http://en.wikipedia.org/wiki/Year_2038_problem
                # Catch the year 2038 problem here as the python dateutil module
                # can choke throwing a ValueError exception. If either the start
                # or end time for an event has a year '>= 2038' dump it.
                if event.s.year >= 2038 or event.e.year >= 2039:
                    continue

                eventList.append(event)

            if not next:
                break

            feed = self.gcal.GetCalendarEventFeed(next.href)

        return eventList


    def _SearchForCalEvents(self, start, end, searchText):

        eventList = []

        queue = Queue()
        threads = []

        def worker(cal, query):
            feed = self.gcal.CalendarQuery(query)
            queue.put((cal, feed))

        for cal in self.cals:

            if not self._CalendarWithinAccess(cal):
                continue

            # see http://code.google.com/apis/calendar/reference.html
            if not searchText:
                query = CalendarEventQuery(cal.gcalcli_username,
                                           cal.gcalcli_visibility,
                                           cal.gcalcli_projection)
                query.start_min = start.isoformat()
                query.start_max = end.isoformat()
            else:
                query = CalendarEventQuery(cal.gcalcli_username,
                                           cal.gcalcli_visibility,
                                           cal.gcalcli_projection,
                                           searchText)
                if start: # flagged by --ignore-started
                    # weeds out old but still pulls in started events
                    query.futureevents = 'true'

            query.singleevents = 'true'

            # we sort later after getting events from all calendars
            #query.orderby = 'starttime'
            #query.sortorder = 'ascending'

            th = threading.Thread(target=worker, args=(cal, query))
            threads.append(th)
            th.start()

        for th in threads:
            th.join()

        while not queue.empty():
            cal, feed = queue.get()
            eventList.extend(self._GetAllEvents(cal, feed, end))

        eventList.sort(lambda x, y: cmp(x.s, y.s))

        return eventList


    def ListAllCalendars(self):

        accessLen = 0

        for cal in self.allCals.entry:
            length = len(cal.access_level.value)
            if length > accessLen: accessLen = length

        if accessLen < len('Access'): accessLen = len('Access')

        format = ' %0' + str(accessLen) + 's  %s\n'

        PrintMsg(CLR_BRYLW(), "\n" + format % ('Access', 'Title'))
        PrintMsg(CLR_BRYLW(), format % ('------', '-----'))

        for cal in self.allCals.entry:
            PrintMsg(self._CalendarColor(cal),
                     format % (cal.access_level.value, cal.title.text))


    def TextQuery(self, searchText=''):

        # the empty string would get *ALL* events...
        if searchText == '':
            return

        if self.ignoreStarted:
            start = self.now # flags gdata futureevents to true
        else:
            start = None

        # convert now to midnight this morning and use for default
        defaultDateTime = self.now.replace(hour=0,
                                           minute=0,
                                           second=0,
                                           microsecond=0)

        eventList = \
            self._SearchForCalEvents(start, None, searchText)

        self._PrintEvents(self.now, eventList)


    def AgendaQuery(self, startText='', endText=''):

        if self.ignoreStarted:
            defaultDateTime = self.now
        else:
            # convert now to midnight this morning and use for default
            defaultDateTime = self.now.replace(hour=0,
                                               minute=0,
                                               second=0,
                                               microsecond=0)

        if startText == '':
            start = defaultDateTime
        else:
            try:
                start = parse(startText, default=defaultDateTime)
            except:
                PrintErrMsg('Error: failed to parse start time\n')
                return

        if endText == '':
            end = (start + timedelta(days=self.agendaLength))
        else:
            try:
                end = parse(endText, default=defaultDateTime)
            except:
                PrintErrMsg('Error: failed to parse end time\n')
                return

        eventList = self._SearchForCalEvents(start, end, None)

        self._PrintEvents(start, eventList)


    def CalQuery(self, cmd, startText='', count=1):

        # convert now to midnight this morning and use for default
        defaultDateTime = self.now.replace(hour=0,
                                           minute=0,
                                           second=0,
                                           microsecond=0)

        if startText == '':
            start = defaultDateTime
        else:
            try:
                start = parse(startText, default=defaultDateTime)
                start = start.replace(hour=0, minute=0, second=0, microsecond=0)
            except:
                PrintErrMsg('Error: failed to parse start time\n')
                return

        # convert start date to the beginning of the week or month
        if cmd == 'calw':
            dayNum = int(start.strftime("%w"))
            if self.calMonday:
                dayNum -= 1
                if dayNum < 0:
                    dayNum = 6
            start = (start - timedelta(days=dayNum))
            end = (start + timedelta(days=(count * 7)))
        else: # cmd == 'calm':
            start = (start - timedelta(days=(start.day - 1)))
            endMonth = (start.month + 1)
            endYear = start.year
            if endMonth == 13:
                endMonth = 1
                endYear += 1
            end = start.replace(month=endMonth, year=endYear)
            daysInMonth = (end - start).days
            offsetDays = int(start.strftime('%w'))
            if self.calMonday:
                offsetDays -= 1
                if offsetDays < 0:
                    offsetDays = 6
            totalDays = (daysInMonth + offsetDays)
            count = (totalDays / 7)
            if totalDays % 7:
                count += 1

        eventList = self._SearchForCalEvents(start, end, None)

        self._GraphEvents(cmd, start, count, eventList)


    def QuickAdd(self, eventText):

        if eventText == '':
            return

        quickEvent = gdata.calendar.CalendarEventEntry()
        quickEvent.content = atom.Content(text=eventText)
        quickEvent.quick_add = gdata.calendar.QuickAdd(value='true')

        self.gcal.InsertEvent(quickEvent, self._TargetCalendar())


    def Remind(self, minutes=10, command=None):

        if command == None:
            command = self.command

        # perform a date query for now + minutes + slip
        start = self.now
        end   = (start + timedelta(minutes=(minutes + 5)))

        eventList = self._SearchForCalEvents(start, end, None)

        message = ''

        for event in eventList:

            # skip this event if it already started
            # XXX maybe add a 2+ minute grace period here...
            if event.s < self.now:
                continue

            if self.military:
                tmpTimeStr = event.s.strftime('%H:%M')
            else:
                tmpTimeStr = \
                    event.s.strftime('%I:%M').lstrip('0') + \
                    event.s.strftime('%p').lower()

            message += '%s  %s\n' % \
                       (tmpTimeStr, self._ValidTitle(event.title.text).strip())

        if message == '':
            return

        message = "Google Calendar Reminder:\n" + message

        cmd = shlex.split(command)

        for i, a in zip(xrange(len(cmd)), cmd):
            if a == '%s':
                cmd[i] = message

        pid = os.fork()
        if not pid:
            os.execvp(cmd[0], cmd)


    def ImportICS(self, icsFile=None):
        try:
            import vobject
        except:
            PrintErrMsg('Python vobject module not installed!\n')
            sys.exit(1)

        f = sys.stdin

        if icsFile:
            try:
                f = file(icsFile)
            except Exception, e:
                PrintErrMsg("Error: " + str(e) + "!\n")
                sys.exit(1)

        while True:

            try:
                v = vobject.readComponents(f).next()
            except StopIteration:
                break

            ve = v.vevent
            event = gdata.calendar.CalendarEventEntry()

            if hasattr(ve, 'summary'):
                DebugPrint("SUMMARY: %s\n" % ve.summary.value)
                event.title = atom.Title(text=ve.summary.value)

            if hasattr(ve, 'location'):
                DebugPrint("LOCATION: %s\n" % ve.location.value)
                event.where = gdata.calendar.Where(value_string=ve.location.value)

            if not hasattr(ve, 'dtstart') or not hasattr(ve, 'dtend'):
                PrintErrMsg("Error: file does not have both dtstart and dtend!\n")
                sys.exit(1)

            DebugPrint("DTSTART: %s\n" % ve.dtstart.value.isoformat())
            DebugPrint("DTEND: %s\n" % ve.dtend.value.isoformat())

            if hasattr(ve, 'rrule'):

                DebugPrint("RRULE: %s\n" % ve.rrule.value)

                #
                # In order to add an RRULE using a DTSTART and DTEND in the
                # local timezone, there needs to be a TIMEZONE section in the
                # recurrence field. Since that is a pain and I'm lazy... as
                # a workaround I convert the DTSTART and DTEND to UTC. Google
                # handles this properly and keys off the timezone setting of
                # the calendar being added to.  The event will be shown at the
                # correct local time. :-)
                #

                if False:
                    # A TIMEZONE section is needed for this to work XXX
                    recurrence = \
                        "DTSTART;TZID=" + \
                            ve.dtstart.value.tzinfo._tzid + ":" + \
                            ve.dtstart.value.strftime('%Y%m%dT%H%M%S') + \
                            '\r\n' + \
                        "DTEND;TZID=" + \
                            ve.dtend.value.tzinfo._tzid + ":" + \
                            ve.dtend.value.strftime('%Y%m%dT%H%M%S') + \
                            '\r\n' + \
                        "RRULE:" + ve.rrule.value + '\r\n'
                else:
                    ve.dtstart.value -= ve.dtstart.value.utcoffset()
                    ve.dtstart.value = ve.dtstart.value.replace(tzinfo=None)
                    ve.dtend.value -= ve.dtend.value.utcoffset()
                    ve.dtend.value = ve.dtend.value.replace(tzinfo=None)
                    recurrence = \
                        "DTSTART:" + \
                            ve.dtstart.value.strftime('%Y%m%dT%H%M%S') + \
                            '\r\n' + \
                        "DTEND:" + \
                            ve.dtend.value.strftime('%Y%m%dT%H%M%S') + \
                            '\r\n' + \
                        "RRULE:" + ve.rrule.value + '\r\n'

                DebugPrint("RECURRENCE:\n%s\n" % recurrence)
                event.recurrence = \
                    gdata.calendar.Recurrence(text=recurrence)

            elif hasattr(ve, 'dtstart') and hasattr(ve, 'dtend'):

                start = ve.dtstart.value.isoformat()
                end   = ve.dtend.value.isoformat()
                event.when = gdata.calendar.When(start_time=start,
                                                 end_time=end)

            if hasattr(ve, 'description'):
                DebugPrint("DESCRIPTION: %s\n" % ve.description.value)
                event.content = atom.Content(text=ve.description.value)

            self.gcal.InsertEvent(event, self._TargetCalendar())


def LoadConfig(configFile):

    config = RawConfigParser()
    config.read(os.path.expanduser(configFile))
    return config


def GetConfig(config, key, default):

    try:
        value = config.get('gcalcli', key)
    except:
        value = default

    return value


def GetConfigMultiple(config, key, default):

    try:
        values = config.get('gcalcli', key)
    except:
        values = default

    if values == None:
        return [ None ]

    valueList = csv.reader([ values ],
                           delimiter=',',
                           quotechar='"',
                           skipinitialspace=True).next()
    return valueList


def GetTrueFalse(value):

    if value.lower() == 'false': return False
    else: return True


def GetColor(value, exitFlag):

    colors = { 'default'       : CLR_NRM(),
               'black'         : CLR_BLK(),
               'brightblack'   : CLR_BRBLK(),
               'red'           : CLR_RED(),
               'brightred'     : CLR_BRRED(),
               'green'         : CLR_GRN(),
               'brightgreen'   : CLR_BRGRN(),
               'yellow'        : CLR_YLW(),
               'brightyellow'  : CLR_BRYLW(),
               'blue'          : CLR_BLU(),
               'brightblue'    : CLR_BRBLU(),
               'magenta'       : CLR_MAG(),
               'brightmagenta' : CLR_BRMAG(),
               'cyan'          : CLR_CYN(),
               'brightcyan'    : CLR_BRCYN(),
               'white'         : CLR_WHT(),
               'brightwhite'   : CLR_BRWHT() }

    try:
        return colors[value]
    except:
        if exitFlag:
            PrintErrMsg('Error: invalid color name\n')
            sys.exit(1)
        else:
            return None


def GetCalColors(calNames):
    calNameColors = []
    for idx in xrange(len(calNames)):
        i = calNames[idx].rfind('#')
        if i != -1:
            c = GetColor(calNames[idx][(i+1):], False)
            if c:
                calNameColors.append(c)
                calNames[idx] = calNames[idx][:i]
            else:
                calNameColors.append(None)
        else:
            calNameColors.append(None)
    return calNames, calNameColors


def BowChickaWowWow():

    try:
        opts, args = getopt.getopt(sys.argv[1:], "",
                                   ["help",
                                    "version",
                                    "config=",
                                    "user=",
                                    "pw=",
                                    "https",
                                    "cals=",
                                    "cal=",
                                    "24hr",
                                    "details",
                                    "ignore-started",
                                    "width=",
                                    "mon",
                                    "nc",
                                    "cal-owner-color=",
                                    "cal-editor-color=",
                                    "cal-contributor-color=",
                                    "cal-read-color=",
                                    "cal-freebusy-color=",
                                    "date-color=",
                                    "border-color="])
    except getopt.error:
        sys.exit(1)

    configFile = '~/.gcalclirc'

    # look for config file override then load the config file
    # we do this first because command line args take precedence
    for opt, arg in opts:
        if opt == "--config": configFile = arg

    cfg = LoadConfig(configFile)

    usr           = GetConfig(cfg, 'user', None)
    pwd           = GetConfig(cfg, 'pw', None)
    https         = GetTrueFalse(GetConfig(cfg, 'https', 'false'))
    access        = GetConfig(cfg, 'cals', 'all')
    calNames      = GetConfigMultiple(cfg, 'cal', None)
    military      = GetTrueFalse(GetConfig(cfg, '24hr', 'false'))
    details       = GetTrueFalse(GetConfig(cfg, 'details', 'false'))
    ignoreStarted = GetTrueFalse(GetConfig(cfg, 'ignore-started', 'false'))
    calWidth      = int(GetConfig(cfg, 'width', '10'))
    calMonday     = GetTrueFalse(GetConfig(cfg, 'mon', 'false'))

    calOwnerColor = \
        GetColor(GetConfig(cfg, 'cal-owner-color', 'cyan'), True)
    calEditorColor = \
        GetColor(GetConfig(cfg, 'cal-editor-color', 'green'), True) 
    calContributorColor = \
        GetColor(GetConfig(cfg, 'cal-contributor-color', 'default'), True)
    calReadColor = \
        GetColor(GetConfig(cfg, 'cal-read-color', 'magenta'), True)
    calFreeBusyColor = \
        GetColor(GetConfig(cfg, 'cal-freebusy-color', 'default'), True)
    dateColor = \
        GetColor(GetConfig(cfg, 'date-color', 'yellow'), True)
    borderColor = \
        GetColor(GetConfig(cfg, 'border-color', 'white'), True)

    # fix wokCalNames when not specified in config file
    if len(calNames) == 1 and calNames[0] == None:
        calNames      = []
        calNameColors = []

    # Process options
    for opt, arg in opts:

        if opt == "--help":
            Usage()

        if opt == "--version":
            Version()

        elif opt == "--user":
            usr = arg

        elif opt == "--pw":
            pwd = arg

        elif opt == "--https":
            https = True

        elif opt == "--cals":
            access = arg

        elif opt == "--cal":
            calNames.append(arg)

        elif opt == "--24hr":
            military = True

        elif opt == "--details":
            details = True

        elif opt == "--ignore-started":
            ignoreStarted = True

        elif opt == "--width":
            calWidth = int(arg)

        elif opt == "--mon":
            calMonday = True

        elif opt == "--nc":
            CLR.useColor = False

        elif opt == "--cal-owner-color":
            calOwnerColor = GetColor(arg, True)

        elif opt == "--cal-editor-color":
            calEditorColor = GetColor(arg, True)

        elif opt == "--cal-contributor-color":
            calContributorColor = GetColor(arg, True)

        elif opt == "--cal-read-color":
            calReadColor = GetColor(arg, True)

        elif opt == "--cal-freebusy-color":
            calFreeBusyColor = GetColor(arg, True)

        elif opt == "--date-color":
            dateColor = GetColor(arg, True)

        elif opt == "--border-color":
            borderColor = GetColor(arg, True)

    if usr == None:
        PrintErrMsg('Error: must specify a username\n')
        sys.exit(1)

    try:
        if pwd == None:
            pwd = getpass.getpass("Password: ")
    except Exception, e:
        PrintErrMsg("Error: " + str(e) + "!\n")
        sys.exit(1)

    if pwd == None or pwd == '':
        PrintErrMsg('Error: must specify a password\n')
        sys.exit(1)

    if len(args) == 0:
        PrintErrMsg('Error: no command (--help)\n')
        sys.exit(1)

    calNames, calNameColors = GetCalColors(calNames)

    gcal = gcalcli(username=usr,
                   password=pwd,
                   https=https,
                   access=access,
                   calNames=calNames,
                   calNameColors=calNameColors,
                   military=military,
                   details=details,
                   ignoreStarted=ignoreStarted,
                   calWidth=calWidth,
                   calMonday=calMonday,
                   calOwnerColor=calOwnerColor,
                   calEditorColor=calEditorColor,
                   calContributorColor=calContributorColor,
                   calReadColor=calReadColor,
                   calFreeBusyColor=calFreeBusyColor,
                   dateColor=dateColor,
                   borderColor=borderColor)

    if args[0] == 'list':
        gcal.ListAllCalendars()

    elif args[0] == 'search':
        if len(args) != 2:
            PrintErrMsg('Error: invalid search string\n')
            sys.exit(1)

        # allow unicode strings for input
        gcal.TextQuery(unicode(args[1], locale.getpreferredencoding()))

        sys.stdout.write('\n')

    elif args[0] == 'agenda':
        if len(args) == 3: # start and end
            gcal.AgendaQuery(startText=args[1], endText=args[2])
        elif len(args) == 2: # start
            gcal.AgendaQuery(startText=args[1])
        elif len(args) == 1: # defaults
            gcal.AgendaQuery()
        else:
            PrintErrMsg('Error: invalid agenda arguments\n')
            sys.exit(1)

        sys.stdout.write('\n')

    elif args[0] == 'calw':
        if not calWidth:
            PrintErrMsg('Error: invalid width, don\'t be an idiot!\n')
            sys.exit(1)

        if len(args) >= 2:
            try:
                count = int(args[1])
            except:
                PrintErrMsg('Error: invalid calw arguments\n')
                sys.exit(1)

        if len(args) == 3: # weeks and start
            gcal.CalQuery(args[0], count=int(args[1]), startText=args[2])
        elif len(args) == 2: # weeks
            gcal.CalQuery(args[0], count=int(args[1]))
        elif len(args) == 1: # defaults
            gcal.CalQuery(args[0])
        else:
            PrintErrMsg('Error: invalid calw arguments\n')
            sys.exit(1)

        sys.stdout.write('\n')

    elif args[0] == 'calm':
        if not calWidth:
            PrintErrMsg('Error: invalid width, don\'t be an idiot!\n')
            sys.exit(1)

        if len(args) == 2: # start
            gcal.CalQuery(args[0], startText=args[1])
        elif len(args) == 1: # defaults
            gcal.CalQuery(args[0])
        else:
            PrintErrMsg('Error: invalid calm arguments\n')
            sys.exit(1)

        sys.stdout.write('\n')

    elif args[0] == 'quick':
        if len(args) != 2:
            PrintErrMsg('Error: invalid event text\n')
            sys.exit(1)

        # allow unicode strings for input
        gcal.QuickAdd(unicode(args[1], locale.getpreferredencoding()))

    elif args[0] == 'remind':
        if len(args) == 3: # minutes and command
            gcal.Remind(int(args[1]), args[2])
        elif len(args) == 2: # minutes
            gcal.Remind(int(args[1]))
        elif len(args) == 1: # defaults
            gcal.Remind()
        else:
            PrintErrMsg('Error: invalid remind arguments\n')
            sys.exit(1)

    elif args[0] == 'import':
        if len(args) == 2: # ics file
            gcal.ImportICS(args[1])
        else:
            gcal.ImportICS() # stdin

    else:
        PrintErrMsg('Error: unknown command (--help)\n')
        sys.exit(1)


if __name__ == '__main__':
    BowChickaWowWow()

